Exercise: Social Media Channels

You just landed a job at "SocialOverlord", a company developing a SaaS product allowing you to write and schedule posts to a variety of social networks.

The whole backend is written in Python (yay!), but unfortunately, the person you're replacing didn't know classes exist (ehrm...) and so they used tuples to represent all the data in the system (not so yay...). Here's a code example:

# each social channel has a type
# and the current number of followers
SocialChannel = tuple[str, int]

# each post has a message
# and the timestamp when it should be posted
Post = tuple[str, int]

def post_a_message(channel: SocialChannel, message: str) -> None:
  type, _ = channel
  if type == "youtube":
    post_to_youtube(channel, message)
  elif type == "facebook":
    post_to_facebook(channel, message)
  elif type == "twitter":
    post_to_twitter(channel, message)

def process_schedule(posts: list[Post],
channels: list[SocialChannel]) -> None:
  for post in posts:
    message, timestamp = post
    for channel in channels:
      if timestamp <= time():
       post_a_message(channel, message)

 

a) From tuples to classes

Refactor this code so that it uses classes instead of tuples to represent social channels and posts. As a starting point, use the code download for this exercise.

b) Improving the post_a_message function

The post_a_message function isn't great. The if-statement has to check for each different type of social network and then call a different method. If you want to add support for a new social network, you'll need to add an extra elif part, making the code harder and harder to read.

Implement a new version of the code that uses abstraction to solve the problem.

Bonus challenge: is there a solution that doesn't need abstraction?

Compatible Python Versions: 3.9+


Aleksei Panin

from time import time
from dataclasses import dataclass, field
from typing import Protocol

@dataclass
class Post:
message: str
timestamp: int

class SocialChannel(Protocol):
def post(self, message: str) -> None:
...

@dataclass
class YoutubeChannel:
followers: int
type: str = field(init=False, default="youtube")

def post(self, message: str) -> None:
print(f"{self.type} channel: {message}")

@dataclass
class FacebookChannel:
followers: int
type: str = field(init=False, default="facebook")

def post(self, message: str) -> None:
print(f"{self.type} channel: {message}")

@dataclass
class TwitterChannel:
followers: int
type: str = field(init=False, default="twitter")

def post(self, message: str) -> None:
print(f"{self.type} channel: {message}")

def process_schedule(posts: list[Post], channels: list[SocialChannel]) -> None:
for post in posts:
for channel in channels:
if post.timestamp <= time():
channel.post(post.message)

def main() -> None:
posts = [
Post("Grandma's carrot cake is available again (limited quantities!)!", 1568123400),
Post("Get your carrot cake now, the promotion ends today!", 1568133400),
]
channels = [
YoutubeChannel(100),
FacebookChannel(100),
TwitterChannel(100),
]
process_schedule(posts, channels)

if __name__ == "__main__":
main()

REPLY
Srihari Ruttala

# each social channel has a type
# and the current number of followers
# SocialChannel = tuple[str, int]

from abc import ABC, abstractmethod
from dataclasses import dataclass
from time import time
@dataclass
class SocialChannel(ABC):
channel_name: str
followers: int

@abstractmethod
def post_a_message(self, message: str) -> None:
raise NotImplementedError("Implement in sub class")

# each post has a message
# and the timestamp when it should be posted
# Post = tuple[str, int]

@dataclass
class Post:
message: str
schedule_time: int

@dataclass
class Youtube(SocialChannel):
def post_a_message(self, message: str) -> None:
print(f"Posting message {message} in youtube")

@dataclass
class Facebook(SocialChannel):
def post_a_message(self, message: str) -> None:
print(f"Posting the message {message} in facebook")

@dataclass
class Twitter(SocialChannel):
def post_a_message(self, message: str) -> None:
print(f"Posting the message {message} in twitter")

def process_schedule(posts: list[Post], channels: list[SocialChannel]) -> None:
for post in posts:
for channel in channels:
if post.schedule_time <= time():
channel.post_a_message(post.message)

if __name__ == "__main__":
youtube = Youtube(channel_name="youtube", followers= 5)
facebook = Facebook(channel_name="facebook", followers=9)
twitter = Twitter(channel_name="twitter", followers=7)
channels: list[SocialChannel] = [youtube, facebook, twitter]
post_1 = Post(message="Hi, how are you", schedule_time= int (time() - 100))
post_2 = Post(message="New announcement today at 8", schedule_time= int (time() + 1100))
posts = [post_1, post_2]
process_schedule(posts, channels)

REPLY
Andreas [ArjanCodes Team]

Nice solution, Srihari! One nitpick from mine is to not have logic in the if __name__ == "__main__": statement since it mainly checks whether a script is being run directly or imported as a module. Usually, you would have a main function defined that is later called in the if-statement

REPLY
Mateusz Mirosławski

Hi,
I have a question to `exercise_1_v4.py`. Why do we pass a `channel: SocialChannel` as a parameter to functions like `post_to_youtube`, `post_to_facebook` and `post_to_twitter`? In my opinion, theese functions should already work for specific type of channel, not for all of possible channels.

REPLY
Andreas [ArjanCodes Team]

The idea is post_to_facebook, post_to_youtube can with this post from different accounts. In a sense, you could have three different YouTube channels to post the same post.

REPLY
Agustin Rodriguez

There are my solutions:
In exercise 1, should I use @property or not?
In exercise 2, I need to search for "from future import annotations" because I don't know this annotation.
Abstract way:
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from time import time

@dataclass
class Post:
"""A social media post."""
message: str
timestamp: int

class SocialChannel(ABC):
"""A social media channel."""
@abstractmethod
def post_a_message(self, message: str) -> None:
""" Post a message to the channel. """

@dataclass
class Youtube(SocialChannel):
"""A YouTube channel."""
followers: int = 100
type: str = field(default="youtube", init=False)
def post_a_message(self, message: str) -> None:
post_to_youtube(self, message)

@dataclass
class Facebook(SocialChannel):
"""A Facebook channel."""
followers: int = 100
type: str = field(default="facebook", init=False)

def post_a_message(self, message: str) -> None:
post_to_facebook(self, message)

@dataclass
class Twitter(SocialChannel):
"""A Twitter channel."""
followers: int = 100
type: str = field(default="twitter", init=False)

def post_a_message(self, message: str) -> None:
post_to_twitter(self, message)

def post_to_youtube(self, message: str) -> None:
"""Post a message to a social media channel."""
print(f"{self.type} channel: {message}")

def post_to_facebook(self, message: str) -> None:
"""Post a message to a social media channel."""
print(f"{self.type} channel: {message}")

def post_to_twitter(self, message: str) -> None:
"""Post a message to a social media channel."""
print(f"{self.type} channel: {message}")

def process_schedule(posts: list[Post], channels: list[SocialChannel]) -> None:
"""Process a schedule of social media posts."""
for post in posts:
for channel in channels:
if post.timestamp <= time():
channel.post_a_message(post.message)

def main() -> None:
posts = [
Post(
"Grandma's carrot cake is available again (limited quantities!)!",
1568123400,
),
Post("Get your carrot cake now, the promotion ends today!", 1568133400),
]
channels = [
Youtube(100),
Facebook(100),
Twitter(100),
]
process_schedule(posts, channels)

if __name__ == "__main__":
main()
With out abstract
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from time import time

@dataclass
class Post:
"""A social media post."""
message: str
timestamp: int

@dataclass
class SocialChannel():
"""A social media channel."""
type: str
followers: int

def post_a_message(self, message: str) -> None:
""" Post a message to the channel. """
print(f"{self.type} channel: {message}")

def process_schedule(posts: list[Post], channels: list[SocialChannel]) -> None:
"""Process a schedule of social media posts."""
for post in posts:
for channel in channels:
if post.timestamp <= time():
channel.post_a_message(post.message)

def main() -> None:
posts = [
Post(
"Grandma's carrot cake is available again (limited quantities!)!",
1568123400,
),
Post("Get your carrot cake now, the promotion ends today!", 1568133400),
]
channels = [
SocialChannel("Youtube",100),
SocialChannel("Facebook",100),
SocialChannel("Twitter",100),
]
process_schedule(posts, channels)

if __name__ == "__main__":
main()

Exercise 2
from __future__ import annotations

from abc import ABC
from dataclasses import dataclass

@dataclass
class HtmlElement(ABC):
"""An HTML element."""
parent: HtmlElement | None = None
x: int = 0
y: int = 0

def compute_screen_position(self) -> tuple[int, int]:
"""Compute the screen position of the element."""
if not self.parent:
return (self.x, self.y)
parent_x, parent_y = self.parent.compute_screen_position()
return (parent_x + self.x, parent_y + self.y)

@dataclass
class Div(HtmlElement):
"""A div element."""

@dataclass
class Button(HtmlElement):
"""A button element."""
def click(self) -> None:
"""Click action."""
print("Click!")

@dataclass
class Span(HtmlElement):
"""A span element."""
text: str = ""

def main() -> None:
root = Div(None, 25, 25)
button = Button(root, 0, 0)
sub_div = Div(root, 100, 100)
span = Span(sub_div, 40, 40, "Hello")
button.click()
print(sub_div.compute_screen_position())

if __name__ == "__main__":
main()

REPLY
Andreas [ArjanCodes Team]

The use of @property is not necessary for this particular exercise since you are not defining any computed properties that require a getter method. Your current implementation works well without @property.

Furthermore, using from future import annotations allows for using type hints with forward references, which is helpful for self-referential classes or where classes refer to each other.

Other than that, this good looks good! Nice use of ABCs!

REPLY
Petr Pošík

In part 2, why shall we make HtmlElement a subclass of ABC? There is no abstract method in HtmlElement, and an instance of HtmlElement can be created although it is an abstract base class. If HtmlElement wouldn't be a subclass ABC, it would work IMHO exactly the same. Am I missing something? Or, is the subclassing of ABC just a kind of documentation, that even if an instance can be created, we should not have to do it?

REPLY
Arjan Egges

Hi Petr, you can indeed see this more as a kind of "documentation". By being explicit that HtmlElement is an ABC, we clarify its purpose: you are supposed to not directly create an instance of this, but use it as a basis for subclasses.

REPLY
Luke Anderson

For exercise 1, do we even need ABCs or Protocols here? As the only difference between a SocialChannel is the type (youtube, facebook, twitter), and no behavioural differences in how we are posting in this simple example. Using inheritance seemed to add a lot of boilerplate code just to override the channel type, where a simpler SocialChannel class with a 'type' attribute would suffice?

```
from dataclasses import dataclass
from time import time

@dataclass
class Post:
message: str
timestamp: float

@dataclass
class SocialChannel:
type: str
followers: int = 0

def post_message(self, message: str) -> None:
print(f"{self.type} channel: {message}")

def post_a_message(channel: SocialChannel, message: str) -> None:
channel.post_message(message)

def process_schedule(posts: list[Post], channels: list[SocialChannel]) -> None:
for post in posts:
for channel in channels:
if post.timestamp <= time():
post_a_message(channel, post.message)

def main() -> None:
posts = [
Post(
message="Grandma's carrot cake is available again (limited quantities!)!",
timestamp=1568123400,
),
Post(
message="Get your carrot cake now, the promotion ends today!",
timestamp=1568133400,
),
]
channels: list[SocialChannel] = [
SocialChannel(type="youtube", followers=100),
SocialChannel(type="facebook", followers=100),
SocialChannel(type="twitter", followers=100),
]
process_schedule(posts, channels)

if __name__ == "__main__":
main()
```

However, in reality, the method of posting would use different APIs for each platform, so the behaviour would indeed change. Is that where I'd refactor into using a protocol to add the channel-specific implementation to a dedicated channel class for posting to that platform?

The post_a_message function could rely on a protocol to call the a post() method on any social channel object that implemented it.

REPLY
Andreas [ArjanCodes Team]

Exactly, you are correct. The behavior would change because they work a bit differently. A way to do it would to use protocols and create classes that follow that protocol.

REPLY
Show More